44 — 程序化地形 Shader
介绍
这节课,我们要用 shader 把一个平面变成一个程序化地形,包含山脉、水、沙滩、高原等等:
我们要用到的技术大部分都已经学过了,这是一个边练习、边发现更多小技巧的机会。
设置
起始代码已经包含以下内容:
- 一个占位球体,应用了一个 MeshStandardMaterial
- 一张 HDR 环境贴图作为背景,同时也用来照亮场景
- 一盏 DirectionalLight,进一步照亮场景并投射阴影
vite-plugin-glsl依赖,用于处理 GLSL 文件OrbitControls,用来围绕旋转three-custom-shader-material依赖,用于增强内置材质
底板
我们先不直接开始做地形,而是先创建它周围的底板。这个底板需要长这样:
虽然看起来很简单,但在一个平面几何体里挖一个洞其实相当困难,有很多方法可以做,但没有简单的方法。
我们可以用只有 4 个分段的 LatheGeometry。
我们可以用 ExtrudeGeometry。
我们可以创建 4 个按特定方式排列的 BoxGeometry:
我们甚至可以在 Blender 这样的 3D 软件里创建模型再导入。
所有这些方案应该都能给你正确的结果,但还有另一个我想让你发现的方案,那就是 three-bvh-csg。
CSG 代表 Constructive Solid Geometry(构造实体几何),BVH 代表 Bounding Volume Hierarchy(包围体层次结构)。虽然非常有趣,但我们不会解释这两个话题。
一言以蔽之,three-bvh-csg 支持几何体之间的布尔运算。举个例子,你可以用一个球体几何体在盒子几何体上戳一个洞。
在我们的情况里,我们要从一个盒子几何体开始,然后创建一个稍小、但在上下都凸出的盒子几何体。接着我们要让后一个在前一个上戳一个洞:
依赖
three-bvh-csg 在 NPM 上可用:https://www.npmjs.com/package/three-bvh-csg
用 npm install 安装这个库,并强制版本为 0.0.16:
npm install three-bvh-csg@0.0.16 我们强制版本是为了确保它能和这节课一起正常工作。
Brush
运算中用到的每个基础形状都称为一个 Brush。
从 'three-bvh-csg' 导入 Brush:
import { Brush } from 'three-bvh-csg' 在 Placeholder 部分之后,添加一个 Board 部分:
/**
* Placeholder
*/
// ...
/**
* Board
*/ 我们需要两个 Brush,一个用于基础形状,一个用来在它上面戳洞。
要实例化一个 Brush,我们需要给它发一个 Three.js 几何体。
我们先从基础形状开始,把它命名为 boardFill,用一个 BoxGeometry 实例化一个 Brush:
// Brushes
const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11)) 我们看不到它,但这个形状是存在的。
接下来,让我们创建会戳洞的 Brush。同样用一个 BoxGeometry 实例化一个新的 Brush,但改变它的大小,让它更紧、但更高。最后,把它命名为 boardHole:
// Brushes
const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11))
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
求值
我们有了两个 brush;现在,我们想用 boardHole 在 boardFill 上戳一个洞。
我们需要实例化一个 Evaluator。
从 'three-bvh-csg' 导入 Evaluator:
import { Evaluator, Brush } from 'three-bvh-csg' 在 Brushes 之后实例化它:
// Evaluate
const evaluator = new Evaluator() 在这个例子里,我们只用 Evaluator 执行一次运算,但注意一个 Evaluator 可以用于多次运算。
我们现在可以用 evaluator 的 evaluate() 方法。这个方法需要两个 Brush 作为最前面的两个参数,还需要运算类型作为第三个参数。
既然我们想戳一个洞,就需要用 SUBTRACTION 运算。
从 'three-bvh-csg' 导入 SUBTRACTION:
import { SUBTRACTION, Evaluator, Brush } from 'three-bvh-csg' 在 evaluator 上调用 evaluate(),把 boardFill、boardHole 和 SUBTRACTION 发给它,并保存为 board:
// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION) 让我们 console.log() 它:
// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
console.log(board)
board 也是一个 Brush,但更重要的是,Brush 似乎继承自 Object3D,意味着我们可以直接把它加到 scene 里:
// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
scene.add(board)
board 就像一个 mesh,目前它上面应用着一个白色的 MeshBasicMaterial。
材质
现在,我们可以把它的材质换成一个好看的 MeshStandardMaterial 就收工,但为了学习,让我们试几件事。
首先,如果你 console.log() board.material 属性,你会看到以下内容:
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
console.log(board.material)
你看到的,是一个材质数组,这是因为 three-bvh-csg 保留了两个初始 Brush 的材质。但这也意味着 boardFill 和 boardHole 也像带有自己材质的 Mesh。
你可以在 evaluate() 之前调整它们的 material 属性来测试:
const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11))
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
boardFill.material.color.set('red')
boardHole.material = new THREE.MeshNormalMaterial()
这之所以可行,是 Three.js 借助我们所说的 groups 实现的。顶点信息被分成组,材质会相应地应用。
你可以对几何体 console.log() 一下 board.geometry.groups 来测试:
console.log(board.geometry.groups)
这些都非常有趣,也可能有用,但对我们来说没用,因为我们想在 board 上用一个简单的 MeshStandardMaterial。
因此,通过在 board.geometry 上调用 clearGroups() 来移除这些组:
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
board.geometry.clearGroups()
并手动给 board.material 属性添加一个 MeshStandardMaterial 实例:
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
board.geometry.clearGroups()
board.material = new THREE.MeshStandardMaterial({ color: '#ffffff', metalness: 0, roughness: 0.3 })
Brush 变换
既然 Brush 继承自 Object3D,我们就可以用传统的 position、rotation 和 scale 来变换它。
同样,我们不需要这么做,但为了学习,让我们把 boardHole 上移,这样给我们的底板加一个底部。
把 boardHole 的 position.y 属性设为 0.2:
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
boardHole.position.y = 0.2
什么都没发生,因为即使我们更新了 position,Three.js 内部使用的、结合了 position、rotation 和 scale 的变换矩阵还没有被更新,因为 Three.js 只在渲染时才更新它们。
我们不想按原样渲染 boardHole,所以,取而代之,我们要自己更新矩阵。
更新 position 之后,在 boardHole 上调用 updateMatrixWorld() 方法:
boardHole.position.y = 0.2
boardHole.updateMatrixWorld()
它起作用了,但让我们去掉这部分,因为地形会填满内部:
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
// boardHole.position.y = 0.2
// boardHole.updateMatrixWorld()
阴影
最后,让我们在 board 上同时开启投射和接收阴影:
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
// ...
board.castShadow = true
board.receiveShadow = true
scene.add(board)
地形基础
让我们开始构建那个地形。
首先,移除占位球体,改为创建一个 Terrain 部分:
/**
* Terrain
*/
几何体
对于几何体,我们要用一个高度细分的 PlaneGeometry:
// Geometry
const geometry = new THREE.PlaneGeometry(10, 10, 500, 500) 与其旋转 Mesh,我们不如旋转几何体本身,这样在计算海拔时就不必考虑模型变换。
在 geometry 上调用 rotateX() 方法,并把 - Math.PI * 0.5 发给它:
const geometry = new THREE.PlaneGeometry(10, 10, 500, 500)
geometry.rotateX(- Math.PI * 0.5)
材质
对于材质,我们想从 MeshStandardMaterial 开始,并用 Custom Shader Material 改进它,后者已经在项目依赖里可用了。
我们要直接实例化 CustomShaderMaterial。
从 'three-custom-shader-material/vanilla' 导入 CustomShaderMaterial:
import CustomShaderMaterial from 'three-custom-shader-material/vanilla' 实例化一个 CustomShaderMaterial,把它保存为 material,并按如下方式设置 Custom Shader Material 和 MeshStandardMaterial:
// Material
const material = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshStandardMaterial,
silent: true,
// MeshStandardMaterial
metalness: 0,
roughness: 0.5,
color: '#85d534'
}) 我们还没有提供任何 shader 或 uniform,但这不会妨碍材质工作。
Mesh
用 geometry 和 material 实例化一个 Mesh,并保存为 terrain:
// Mesh
const terrain = new THREE.Mesh(geometry, material) 把它设为接收和投射阴影,并添加到 scene:
// Mesh
const terrain = new THREE.Mesh(geometry, material)
terrain.receiveShadow = true
terrain.castShadow = true
scene.add(terrain)
Shader
在 src/shaders/ 文件夹里,创建一个 terrain/ 文件夹。
在 src/shaders/terrain/ 文件夹里,创建一个 vertex.glsl 文件,内容如下:
void main()
{
} 仍然在 src/shaders/terrain/ 文件夹里,创建一个 fragment.glsl 文件,内容如下:
void main()
{
csm_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
} 在 script.js 里,把两者分别导入为 terrainVertexShader 和 terrainFragmentShader:
import terrainVertexShader from './shaders/terrain/vertex.glsl'
import terrainFragmentShader from './shaders/terrain/fragment.glsl' 用 vertexShader 和 fragmentShader 属性把它们加到 CustomShaderMaterial:
const material = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshStandardMaterial,
vertexShader: terrainVertexShader,
fragmentShader: terrainFragmentShader,
// ...
}
地形是红色的,说明 shader 在工作。
在 fragment.glsl 里,移除 csm_FragColor 那一行:
void main()
{
}
基础海拔
我们现在想让顶点上下移动来创建地形的海拔,这个过程会和我们在 Raging Sea 那节课里做的非常相似,但这一次用的是 Simplex Noise。
getElevation 函数
我们打算把和海拔相关的代码放进一个 getElevation 函数里。
在 vertex.glsl 里,在 main() 之前,创建一个 getElevation 方法,根据一个 vec2 position 返回一个 float elevation:
float getElevation(vec2 position)
{
float elevation = 0.0;
return elevation;
}
void main()
{
} 我们用 vec2 参数,是因为海拔只随 x 和 z 轴变化。
在 main() 函数里,调用 getElevation,给它发一个 csm_Position 的 xz swizzle,并保存为 float elevation 变量:
void main()
{
// Elevation
float elevation = getElevation(csm_Position.xz);
} 把它加到 csm_Position 的 y 上:
void main()
{
// Elevation
float elevation = getElevation(csm_Position.xz);
csm_Position.y += elevation;
}
Simplex Noise
为了更新 elevation,我们要用一个 Simplex Noise,它已经在 src/shaders/includes/simplexNoise2d.glsl 里可用了。
我们用 2D Simplex Noise,因为它足够了。尽管 Simplex Noise 随维度的扩展相当好,但出于性能考虑,尽量使用最简单的函数。
在 getElevation 函数之前,包含 ../includes/simplexNoise2d.glsl:
#include ../includes/simplexNoise2d.glsl
float getElevation(vec2 position)
{
// ...
} 在 getElevation 函数里,调用 simplexNoise2d() 并把 position 发给它。把结果加到 elevation:
float getElevation(vec2 position)
{
float elevation = 0.0;
elevation += simplexNoise2d(position);
return elevation;
}
它算是起作用了,但明暗似乎坏了。
修复法线
就像往常从 vertex shader 更新顶点时一样,我们需要修复法线。而要计算法线,我们要像在 Raging Sea 那节课里一样,用经典的邻居技术。
既然我们已经做过多次,就快速处理它。
首先,我们需要找到两个邻居的位置,我们把它们命名为 A(在正 x 轴方向)和 B(在负 z 轴方向)。两者的距离都是 shift:
在 main() 函数开头,创建一个值为 0.01 的 float shift 变量:
void main()
{
// Neighbours positions
float shift = 0.01;
// ...
} 基于 position 创建一个 vec3 positionA 变量,在 x 轴上走 shift 的距离:
void main()
{
// Neighbours positions
float shift = 0.01;
vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);
// ...
} 对 positionB 做同样的事,但在负 z 上走 shift 的距离:
void main()
{
// Neighbours positions
float shift = 0.01;
vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);
vec3 positionB = position.xyz + vec3(0.0, 0.0, - shift);
// ...
} 朝负 z 方向走很重要;否则,法线会上下颠倒。
另外注意我们用的是 position 而不是 csm_Position,但两者效果相同。
接下来,我们需要把海拔应用到邻居上:
在我们计算 elevation 以更新 csm_Position.y 的地方,做同样的事,根据各自的 xz 直接更新 positionA.y 和 positionB.y:
void main()
{
// ...
// Elevation
float elevation = getElevation(csm_Position.xz);
csm_Position.y += elevation;
positionA.y += getElevation(positionA.xz);
positionB.y += getElevation(positionB.xz);
} 是时候计算法线了:
计算从 csm_Position 到 positionA 以及到 positionB 的向量,把它们归一化,并保存为 vec3 toA 和 vec3 toB:
void main()
{
// ...
// Compute normal
vec3 toA = normalize(positionA - csm_Position);
vec3 toB = normalize(positionB - csm_Position);
} 我们终于可以用叉积来计算最终法线了:
用 cross 函数计算 toA 与 toB 的叉积,并把输出赋给 csm_Normal:
void main()
{
// ...
// Compute normal
vec3 toA = normalize(positionA - csm_Position);
vec3 toB = normalize(positionB - csm_Position);
csm_Normal = cross(toA, toB);
}
既然我们不再使用原始的 normal attribute 了,我们不仅可以移除几何体 attributes 里的 normal,还可以移除 uv。
在 script.js 里,创建 geometry 之后,用 deleteAttribute() 方法删除 'uv' 和 'normal' attributes:
const geometry = new THREE.PlaneGeometry(10, 10, 500, 500)
geometry.deleteAttribute('uv')
geometry.deleteAttribute('normal')
海拔频率
我们现在可以着手改进海拔了。
首先,我们想降低频率。
目前,我们直接在 getElevation 里创建一个 uPositionFrequency 变量,但之后我们会用 uniforms,这样就能调节它们。
在 getElevation 里,创建一个值为 0.2 的 float uPositionFrequency 变量,并把 position 乘以它:
float getElevation(vec2 position)
{
float uPositionFrequency = 0.2;
float elevation = 0.0;
elevation += simplexNoise2d(position * uPositionFrequency);
return elevation;
}
为了制造变化,我们要在 elevation 上应用更多 simplexNoise2d,并在每个 simplexNoise2d 之间把频率乘以二:
float getElevation(vec2 position)
{
float uPositionFrequency = 0.2;
float elevation = 0.0;
elevation += simplexNoise2d(position * uPositionFrequency );
elevation += simplexNoise2d(position * uPositionFrequency * 2.0);
elevation += simplexNoise2d(position * uPositionFrequency * 4.0);
return elevation;
}
它起作用了,但每个 simplexNoise2d 的强度都一样。我们想让更高频率对海拔的影响更小,但也要确保最终 elevation 永远不会超过 1.0。让 elevation 保持在 1.0 以下,对之后的效果会很有用。
为此,我们要把第一个 simplexNoise2d 除以 2.0,下一个除以 4.0,再下一个除以 8.0:
float getElevation(vec2 position)
{
float uPositionFrequency = 0.2;
float elevation = 0.0;
elevation += simplexNoise2d(position * uPositionFrequency ) / 2.0;
elevation += simplexNoise2d(position * uPositionFrequency * 2.0) / 4.0;
elevation += simplexNoise2d(position * uPositionFrequency * 4.0) / 8.0;
return elevation;
}
像我们在 Raging Sea 那节课里那样在这里创建一个自动循环会很好,但让我们保持简单、继续前进。
高原
在现实生活中,我们往往在接近海平面的地方看到高原。这可能是自然原因,也可能是人为干预,但无论如何,我们想在程序化地形里得到同样的效果。
要做到这一点,我们会在值接近 0 时把它压下去。如果你记得上一课,我们可以用幂:
这就是我们确保 elevation 永远不会超过 1.0 的原因。
在应用 simplexNoise2d() 之后,对 elevation 应用 2.0 的 pow:
float getElevation(vec2 position)
{
// ...
elevation = pow(elevation, 2.0);
return elevation;
}
在这个阶段,一些 Linux 用户可能会遇到一个 bug:地形的一部分不可见。这发生在对负值使用 pow 的时候。别担心,它过一会儿会自行修复。
它起作用了,但我们失去了 elevation 应该低于 0 的裂缝。
原因是 2 次幂意味着我们把同一个数乘以它自己。举个例子 0.5 * 0.5 = 0.25。但在负数的情况下,-0.5 * -0.5 = 0.25。当你把一个负数乘以另一个负数时,这个值就变成正的了。
要修复那个,我们要在应用 pow 之前取出 elevation 的符号,我们可以用 sign 函数来做到:
float getElevation(vec2 position)
{
// ...
float elevationSign = sign(elevation);
elevation = pow(elevation, 2.0);
return elevation;
} elevationSign 对于负值会是 -1.0,对于正值会是 1.0。
我们现在可以把 pow 乘以 elevationSign:
float getElevation(vec2 position)
{
// ...
float elevationSign = sign(elevation);
elevation = pow(elevation, 2.0) * elevationSign;
return elevation;
}
很好,修好了。嗯,其实没有。如果我们把幂设为 3.0,它又会坏掉:
float getElevation(vec2 position)
{
// ...
elevation = pow(elevation, 3.0) * elevationSign;
// ...
}
原因是奇数次幂不需要我们刚刚应用的技巧。举个例子 -0.5 * -0.5 * -0.5 = -0.125。
要修复那个并确保我们总是得到正确的值,我们会在 pow 内部对 elevation 使用 abs 函数,让它始终为正:
float getElevation(vec2 position)
{
// ...
float elevationSign = sign(elevation);
elevation = pow(abs(elevation), 3.0) * elevationSign;
return elevation;
}
遇到过前面提到的 bug 的 Linux 用户在这里应该能让它正常工作了。
让我们把幂改回 2.0:
float getElevation(vec2 position)
{
// ...
elevation = pow(abs(elevation), 2.0) * elevationSign;
// ...
}
强度
就像我们为频率创建了一个 float uPositionFrequency 变量一样,让我们为强度创建一个 float uStrength 变量,并设为 2.0:
float getElevation(vec2 position)
{
float uPositionFrequency = 0.2;
float uStrength = 2.0;
// ...
} 在 pow 之后把它乘到 elevation 上:
float getElevation(vec2 position)
{
// ...
elevation *= uStrength;
return elevation;
}
之后,我们会把这些变量替换成真正的 uniforms。
扭曲
海拔看起来漂亮又自然,但我们要通过扭曲 position 来加入更多多样性。
基于 position 创建一个 vec2 warpedPosition 变量,并在全部 3 个 simplexNoise2d() 上使用它:
float getElevation(vec2 position)
{
// ...
vec2 warpedPosition = position;
float elevation = 0.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency ) / 2.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 2.0) / 4.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 4.0) / 8.0;
// ...
} 创建一个值为 5.0 的 float uWarpFrequency 和一个值为 0.5 的 float uWarpStrength:
float getElevation(vec2 position)
{
float uPositionFrequency = 0.2;
float uStrength = 2.0;
float uWarpFrequency = 5.0;
float uWarpStrength = 0.5;
// ...
} 给 warpedPosition 加一个 simplexNoise2d()。参数用 warpedPosition 乘以 uPositionFrequency(这样它跟随位置频率)再乘以 uWarpFrequency:
float getElevation(vec2 position)
{
// ...
vec2 warpedPosition = position;
warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency);
// ...
}
把整个 simplexNoise2d() 乘以 uWarpStrength:
float getElevation(vec2 position)
{
// ...
vec2 warpedPosition = position;
warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;
// ...
}
海拔看起来更异域了,那些波纹几乎看起来像侵蚀。
Uniform
是时候把 uPositionFrequency、uStrength、uWarpFrequency 和 uWarpStrength 变成 uniforms,这样我们就能调节它们了。
如果你记得前几课,我们会遇到一个阴影问题,需要我们提供一个自定义的 MeshDepthMaterial。我们稍后做,但这说明我们需要给两个材质提供 uniforms,这就是为什么我们要把这些 uniforms 创建一次、用在两个材质上。
在 script.js 里,在 material 之前,创建一个 uniforms 对象,并用 Uniform 类按如下方式设置它的属性:
// Material
const uniforms = {
uPositionFrequency: new THREE.Uniform(0.2),
uStrength: new THREE.Uniform(2.0),
uWarpFrequency: new THREE.Uniform(5),
uWarpStrength: new THREE.Uniform(0.5),
}
const material = new CustomShaderMaterial({
// ...
}) 设置 uniforms 对象之后,立即创建对应的调节项:
const uniforms = {
// ...
}
gui.add(uniforms.uPositionFrequency, 'value', 0, 1, 0.001).name('uPositionFrequency')
gui.add(uniforms.uStrength, 'value', 0, 10, 0.001).name('uStrength')
gui.add(uniforms.uWarpFrequency, 'value', 0, 10, 0.001).name('uWarpFrequency')
gui.add(uniforms.uWarpStrength, 'value', 0, 1, 0.001).name('uWarpStrength')
把 uniforms 对象发给 CustomShaderMaterial 的 uniforms 属性:
const slicedMaterial = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshStandardMaterial,
vertexShader: terrainVertexShader,
fragmentShader: terrainFragmentShader,
uniforms: uniforms,
silent: true,
// ...
}) 在 vertex.glsl 里,把 4 个 uniforms 取回为 float,并从 getElevation 函数里删掉那些临时变量:
uniform float uPositionFrequency;
uniform float uStrength;
uniform float uWarpFrequency;
uniform float uWarpStrength;
// ...
float getElevation(vec2 position)
{
vec2 warpedPosition = position;
warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;
// ...
}
平移
为了给整体增添更多生气,我们要让地形持续移动。
要做到这一点,我们要把时间应用到 warpedPosition 上。
在 script.js 里,用 Uniform 类添加 uTime uniform:
const uniforms = {
uTime: new THREE.Uniform(0),
// ...
} 在 tick 函数里,用 elapsedTime 变量更新 uniforms.uTime 的 value:
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Uniforms
uniforms.uTime.value = elapsedTime
// ...
} 在 vertex.glsl 里,把 uTime uniform 取回为 float:
uniform float uTime; 在 getElevation 函数里,把它加到 warpedPosition 上:
float getElevation(vec2 position)
{
vec2 warpedPosition = position;
warpedPosition += uTime;
// ...
}
这导致 warpedPosition 的 x 和 y 都受到影响。
通过把 uTime 乘以 0.2 来放慢动画:
float getElevation(vec2 position)
{
vec2 warpedPosition = position;
warpedPosition += uTime * 0.2;
// ...
}
修复阴影
虽然由于地形细节丰富,它不那么明显,但投影是坏的。
就像上一课一样,我们需要提供一个自定义深度材质。
在 script.js 里,复制 material 并把它命名为 depthMaterial:
const material = new CustomShaderMaterial({
// ...
})
const depthMaterial = new CustomShaderMaterial({
// ...
}) 把 baseMaterial 改成 THREE.MeshDepthMaterial:
const depthMaterial = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshDepthMaterial,
// ...
}) 移除和 MeshStandardMaterial 相关的属性,并添加我们唯一需要的、和 MeshDepthMaterial 相关的属性,也就是把 depthPacking 设为 THREE.RGBADepthPacking:
const depthMaterial = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshDepthMaterial,
vertexShader: terrainVertexShader,
fragmentShader: terrainFragmentShader,
uniforms: uniforms,
silent: true,
// MeshDepthMaterial
depthPacking: THREE.RGBADepthPacking
}) 移除 fragmentShader,因为在这个情况下它不会改变任何东西:
const depthMaterial = new CustomShaderMaterial({
// CSM
baseMaterial: THREE.MeshDepthMaterial,
vertexShader: terrainVertexShader,
uniforms: uniforms,
silent: true,
// MeshDepthMaterial
depthPacking: THREE.RGBADepthPacking
}) 把 depthMaterial 赋给 terrain 的 customDepthMaterial 属性:
const terrain = new THREE.Mesh(geometry, material)
terrain.customDepthMaterial = depthMaterial
颜色
我们要添加各种颜色,并且根据海拔来做:
有些颜色会突变,有些会平滑过渡。
除此之外,我们要添加一个岩石颜色,但只在接近垂直的面上。
首先,在 fragment.glsl 里,创建一个 vec3 color 变量,把它设为白色并发送给 csm_DiffuseColor:
void main()
{
// Color
vec3 color = vec3(1.0);
// Final color
csm_DiffuseColor = vec4(color, 1.0);
}
我们添加的每种颜色都会更新 color。
Uniform
我们需要 6 种颜色。与其一个一个创建、或者等到最后才添加 uniforms,不如一开始就把它们全部加上:
- water deep:
#002b3d - water surface:
#66a8ff - sand:
#ffe894 - grass:
#85d534 - snow:
#ffffff - rock:
#bfbd8d
我们要用惯常的技巧,把颜色作为独立于 Three.js 的属性来创建,给调试面板添加相应的调节项,并把它们的值注入 Color uniforms。
在 script.js 里,就在 uniforms 对象之前,把以下颜色加到 debugObject:
// Material
debugObject.colorWaterDeep = '#002b3d'
debugObject.colorWaterSurface = '#66a8ff'
debugObject.colorSand = '#ffe894'
debugObject.colorGrass = '#85d534'
debugObject.colorSnow = '#ffffff'
debugObject.colorRock = '#bfbd8d'
const uniforms = {
// ...
} 用 Color 类和 Uniform 类把这些属性注入到对应的 uniforms:
const uniforms = {
// ...
uColorWaterDeep: new THREE.Uniform(new THREE.Color(debugObject.colorWaterDeep)),
uColorWaterSurface: new THREE.Uniform(new THREE.Color(debugObject.colorWaterSurface)),
uColorSand: new THREE.Uniform(new THREE.Color(debugObject.colorSand)),
uColorGrass: new THREE.Uniform(new THREE.Color(debugObject.colorGrass)),
uColorSnow: new THREE.Uniform(new THREE.Color(debugObject.colorSnow)),
uColorRock: new THREE.Uniform(new THREE.Color(debugObject.colorRock)),
} 用 addColor() 方法添加对应的调节项:
gui.addColor(debugObject, 'colorWaterDeep')
gui.addColor(debugObject, 'colorWaterSurface')
gui.addColor(debugObject, 'colorSand')
gui.addColor(debugObject, 'colorGrass')
gui.addColor(debugObject, 'colorSnow')
gui.addColor(debugObject, 'colorRock') 在每个 addColor() 之后用 onChange() 方法,通过 set() 方法更新对应的 uniform:
gui.addColor(debugObject, 'colorWaterDeep').onChange(() => uniforms.uColorWaterDeep.value.set(debugObject.colorWaterDeep))
gui.addColor(debugObject, 'colorWaterSurface').onChange(() => uniforms.uColorWaterSurface.value.set(debugObject.colorWaterSurface))
gui.addColor(debugObject, 'colorSand').onChange(() => uniforms.uColorSand.value.set(debugObject.colorSand))
gui.addColor(debugObject, 'colorGrass').onChange(() => uniforms.uColorGrass.value.set(debugObject.colorGrass))
gui.addColor(debugObject, 'colorSnow').onChange(() => uniforms.uColorSnow.value.set(debugObject.colorSnow))
gui.addColor(debugObject, 'colorRock').onChange(() => uniforms.uColorRock.value.set(debugObject.colorRock))
在 fragment.glsl 里,把全部 6 个 uniforms 取回为 vec3:
uniform vec3 uColorWaterDeep;
uniform vec3 uColorWaterSurface;
uniform vec3 uColorSand;
uniform vec3 uColorGrass;
uniform vec3 uColorSnow;
uniform vec3 uColorRock;
Position varying
我们想根据海拔使用那些颜色。
我们需要来自 vertex shader 的那个海拔,但与其只发 elevation,我们不如发整个 csm_Position,这很快就会派上用场。
在 vertex.glsl 里,创建一个 vec3 vPosition varying,并把 csm_Position 发给它:
varying vec3 vPosition;
// ...
void main()
{
// ...
// Varyings
vPosition = csm_Position;
} 在 fragment.glsl 里,取回 vec3 vPosition varying:
varying vec3 vPosition;
水
我们要从下往上进行,先从水开始。
我们想在 vPosition.y 从 -1.0 到 -0.1 时,在 uColorWaterDeep 和 uColorWaterSurface 之间混合。
我们要在 vPosition.y 上用一个从 -1.0 到 -0.1 的 smoothstep,并把结果保存为 float surfaceWaterMix:
void main()
{
// ...
// Water
float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y);
// ...
} 接下来,用 mix 函数根据 surfaceWaterMix 在 uColorWaterDeep 和 uColorWaterSurface 之间混合,并把结果赋给 color:
void main()
{
// ...
// Water
float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y);
color = mix(uColorWaterDeep, uColorWaterSurface, surfaceWaterMix);
// ...
}
欢迎来到水世界。
沙
我们要明确地划分水和沙。
与其使用 if 语句,我们不如用 step 函数。
在水那部分之后,对 vPosition.y 用一个阈值 -0.1 的 step,并把它赋给 float sandMix 变量:
void main()
{
// ...
// Water
// ...
// Sand
float sandMix = step(- 0.1, vPosition.y);
// ...
} 用 mix 函数根据 sandMix 在 color 和 uColorSand 之间混合,并把结果赋给 color:
void main()
{
// ...
// Sand
float sandMix = step(- 0.1, vPosition.y);
color = mix(color, uColorSand, sandMix);
// ...
}
草地
让我们走完全相同的流程,但这次是草地。
把变量命名为 grassMix,并把阈值设为 -0.06:
void main()
{
// ...
// Sand
// ...
// Grass
float grassMix = step(- 0.06, vPosition.y);
// ...
} 用 mix 函数根据 grassMix 在 color 和 uColorGrass 之间混合,并把结果赋给 color:
void main()
{
// ...
// Grass
float grassMix = step(- 0.06, vPosition.y);
color = mix(color, uColorGrass, grassMix);
// ...
}
雪
再来一次,但这次是雪。
把变量命名为 snowMix,并把阈值设为 0.45:
void main()
{
// ...
// Grass
// ...
// Snow
float snowMix = step(0.45, vPosition.y);
// ...
} 用 mix 函数根据 snowMix 在 color 和 uColorSnow 之间混合,并把结果赋给 color:
void main()
{
// ...
// Snow
float snowMix = step(0.45, vPosition.y);
color = mix(color, uColorSnow, snowMix);
// ...
}
看起来还行,但太规律了。
我们要用惯常的 Simplex Noise 函数根据 vPosition.xz 添加变化,并把那个 Simplex Noise 应用到 0.45 阈值上。
在 main() 之前,包含 ../includes/simplexNoise2d.glsl:
#include ../includes/simplexNoise2d.glsl 创建一个 float snowThreshold 变量,赋给它 0.45。然后,把它用在 step 上:
void main()
{
// ...
// Snow
float snowThreshold = 0.45;
float snowMix = step(snowThreshold, vPosition.y);
color = mix(color, uColorSnow, snowMix);
// ...
} 没有区别,但我们现在可以把 Simplex Noise 应用到 snowThreshold 上了。
根据 vPosition.xz 调用 simplexNoise2d(),并把结果加到 snowThreshold:
void main()
{
// ...
// Snow
float snowThreshold = 0.45;
snowThreshold += simplexNoise2d(vPosition.xz);
// ...
}
通过把 vPosition.xz 乘以 15.0 来提高频率:
void main()
{
// ...
// Snow
float snowThreshold = 0.45;
snowThreshold += simplexNoise2d(vPosition.xz * 15.0);
// ...
}
通过把整个 simplexNoise2d() 乘以 0.1 来降低强度:
void main()
{
// ...
// Snow
float snowThreshold = 0.45;
snowThreshold += simplexNoise2d(vPosition.xz * 15.0) * 0.1;
// ...
}
这基本上就是我们要的,但图案没有跟随动画。
这是由我们在 vertex.glsl 和 getElevation 函数里做的事导致的,我们把 uTime 应用到了 warpedPosition。
要修复那个,把同样的 uTime 加到 vPosition.xz:
void main()
{
// ...
// Varyings
vPosition = csm_Position;
vPosition.xz += uTime * 0.2;
}
岩石
对于岩石,我们想让它在地表几乎垂直时可见,但只在草地高度之上。
我们要用点积(法线的点积)。
与其把整个法线发给 fragment,我们不如在 vertex shader 里计算点积,只把结果发给 fragment shader。
在 vertex.glsl 里,创建一个 float vUpDot varying:
varying float vUpDot; 在 main() 里,对 csm_Normal 和一个正上方的向量使用 dot 函数:
void main()
{
// ...
vUpDot = dot(csm_Normal, vec3(0.0, 1.0, 0.0));
} 在 fragment.glsl 里,取回 float vUpDot varying:
varying float vUpDot; 我们不想让岩石遮住雪,这就是为什么我们把它放在雪之前。
创建一个 float rockMix 变量,赋给它 vUpDot:
void main()
{
// ...
// Rock
float rockMix = vUpDot;
// Snow
// ...
} 目前,当面完全朝上时 vUpDot 返回 1.0,当面完全朝侧面时返回 0.0:
我们可以在 vUpDot 上用阈值 0.8 的 step:
void main()
{
// ...
// Rock
float rockMix = vUpDot;
rockMix = step(0.8, rockMix);
// ...
} 然后用 mix 函数根据 rockMix 在 color 和 uColorRock 之间混合,并把结果赋给 color:
void main()
{
// ...
// Rock
float rockMix = vUpDot;
rockMix = step(0.8, rockMix);
color = mix(color, uColorRock, rockMix);
// ...
}
不幸的是,它和我们要的相反。
我们可以通过用 1.0 减去 step 来轻松修复:
void main()
{
// ...
// Rock
float rockMix = vUpDot;
rockMix = 1.0 - step(0.8, rockMix);
color = mix(color, uColorRock, rockMix);
// ...
}
或者把 step 的两个参数反过来,但让我们坚持用更容易理解的做法。
岩石目前在水里也可见。要修复那个,我们可以把 rockMix 乘以我们给草地用过的同一个 step:
void main()
{
// ...
// Rock
float rockMix = vUpDot;
rockMix = 1.0 - step(0.8, rockMix);
rockMix *= step(- 0.06, vPosition.y);
color = mix(color, uColorRock, rockMix);
// ...
}
很好!现在颜色部分就完成了。
水面
让我们的程序化世界闪耀的最后一项,是添加一个水面。我们希望那个面对其下方的东西折射,把它模糊掉,并反射环境贴图和光。
我们要用 MeshPhysicalMaterial 和它的 transmission 特性。
在 script.js 里,在 Terrain 部分之后,用 PlaneGeometry 和 MeshPhysicalMaterial 添加一个 water Mesh:
/**
* Water
*/
const water = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10, 1, 1),
new THREE.MeshPhysicalMaterial()
)
scene.add(water)
旋转这个 Mesh 并把它下移到 -0.1,让它和 fragment shader 里水停止的位置匹配:
const water = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10, 1, 1),
new THREE.MeshPhysicalMaterial()
)
water.rotation.x = - Math.PI * 0.5
water.position.y = - 0.1
scene.add(water)
把 MeshPhysicalMaterial 的 transmission 属性设为 1,roughness 设为 0.3:
const water = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10, 1, 1),
new THREE.MeshPhysicalMaterial({
transmission: 1,
roughness: 0.3
})
)
water.rotation.x = - Math.PI * 0.5
water.position.y = - 0.1
scene.add(water)
更进一步
我们完成了,但还有改进的空间。
这里有一些建议:
- 试试有趣的颜色
- 改进颜色混合的方式
- 添加纹理
- 添加生物群系
- 用拖放控制平移
- 添加云
- 给底板添加细节和纹理